# ===========================================================================
# Project:   Abbot - SproutCore Build Tools
# Copyright: ©2009 Apple Inc.
#            portions copyright @2006-2011 Strobe Inc.
#            and contributors
# ===========================================================================

require 'thread'

module SC
  module Rack

    # A Rack application for serving dynamically-built SproutCore projects.
    # Most of the time you will use this application as part of the sc-server
    # command to dynamically build your SproutCore project while you develop
    # it.
    #
    # If you are deploying some Ruby-based infrastructure in your production
    # environment, you could also use this application to dynamically build
    # new versions of your SproutCore apps when you deploy them.  This would
    # allow you to potentially bypass the pre-deployment build step using
    # sc-build.
    #
    # While this model is supported by the Rack adaptor, it is generally
    # recommended that you instead build you app without using this adaptor
    # since the build step will help catch possible errors in your code before
    # you go live with your project.  Sometimes, however, dynamically building
    # content is useful, and that is what this adaptor is for.
    #
    # === Using This Application
    #
    # When you instantiate a builder, you must provide one or more projects
    # that contain the resources you want to load.  Each incoming request url
    # will be mapped to an entriy in a project manifest.  The entry is then
    # built and the resulting file returned.  Once a file has been built, it
    # will not be rebuilt unless the source file it represents has changed.
    #
    # In addition to dynamically building entries, the Builder can also
    # forwards requests onto an SC::Rack::Proxy app to handle proxies
    # requests.
    #
    # === Config Settings
    #
    # This app respects several options that you can name in your config file
    # (in addition to proxy configs), that can affect the app performance.
    # Normally reasonable defaults for these settings are built into the
    # SproutCore buildfile, but you may choose to override them if you are
    # deploying into a production environment.
    #
    #  :reload_project::  If set to true, then the builder will reload the
    #    projects to look for changed files before servicing incoming
    #    requests.  You will generally want this option while working in
    #    debug mode, but you may want to disable it for production, since it
    #    can slow down performance.
    #
    #  :use_cached_headers:: If set to true, then the builder will return
    #    static assets with an "Expires: <10-years>" header attached.  This
    #    will yield excellent performance in production systems but it may
    #    interfere with loading the most recent copies of files when in
    #    development mode.
    #
    #  :combine_javascript:: If set, the generated html will reference a
    #    combined version of the javascript for elgible targets.  This will
    #    yield better performance in production, but slows down load time in
    #    development mode.
    #
    #  :combine_stylesheets:: Ditto to combine_javascript
    #
    class Builder

      # used to set expires header.
      ONE_YEAR = 365 * 24 * 60 * 60

      # When you create a new builder, pass in one or more projects you want
      # the builder to monitor for changes.
      def initialize(project)
        @project = project
        @last_reload_time = Time.now
      end

      # Main entry point for this Rack application.  Returns 404 if no
      # matching entry could be found in the project.
      def call(env)
        # define local variables so they will survive the mutext contexts
        # below...
        ret = url = target = language = cacheable = manifest = entry = nil
        build_path = nil

        project_mutex.synchronize do
          did_reload = reload_project! # if needed

          # set SCRIPT_NAME to correctly set namespaces
          $script_name = env["SCRIPT_NAME"]

          # collect some standard info
          url = env['PATH_INFO']
          url = '/sproutcore/welcome' if url == '/'

          #designer mode?
          $design_mode = ((/designMode=YES/ =~ env['QUERY_STRING']) != nil) ? true : false

          # look for a matching target
          target = target_for(url)
          ret = not_found("No matching target") if target.nil?

          # normalize url to resolve to entry & extract the language
          if ret.nil?
            url, language, cacheable = normalize_url(url, target)
            ret = not_found("Target requires language") if language.nil?
          end

          # lookup manifest
          if ret.nil?
            language = language.to_s.downcase.to_sym # normalize
            manifest = target.manifest_for(:language => language).build!

            # lookup entry by url
            unless entry = manifest.entries.find { |e| e[:url] == url }
              ret = not_found("No matching entry in target")
            end
          end

          if ret.nil?
            build_path = entry[:build_path]
            if [:html, :test].include?(entry[:entry_type])
              #if did_reload || !File.exist?(build_path)
              #always clean html files...
              SC.profile("PROFILE_BUILD") do
                entry.clean!.build!
              end
            else
              entry.build!
            end

          end

          # Update last reload time.  This way if any other requests are
          # waiting, they won't rebuild their manifest.
          @last_reload_time = Time.now
        end

        return ret unless ret.nil?

        unless File.file?(build_path) && File.readable?(build_path)
          return not_found("File could not build (entry: #{entry.filename} - build_path: #{build_path}")
        end

        SC.logger.info "Serving #{target[:target_name].to_s.sub(/^\//,'')}:#{entry[:filename]}"

        # define response headers
        file_size = File.size(build_path)
        headers = {
          #"Last-Modified"  => File.mtime(build_path).httpdate,
          #"Etag"           => File.mtime(build_path).to_i.to_s,
          "Content-Type"   => mime_type(build_path, target.config[:mime_types]),
          "Content-Length" => file_size.to_s,
          "Expires"        => (cacheable ? (Time.now + ONE_YEAR) : Time.now).httpdate
        }
        [200, headers, File.open(build_path, 'rb')]
      end

      attr_reader :project

      protected

      # Mutex used while updating the project and retrieving the entry to
      # build.
      def project_mutex; @project_mutex ||= Mutex.new; end

      # Mutex used while building an entry...
      def build_mutex; @build_mutex ||= Mutex.new; end

      # Invoked when a resource cannot be found for some reason
      def not_found(reason)
        reason = "<html><body><p>#{reason}</p></body></html>"
        return [404, {
          "Content-Type"   => "text/html",
          "Content-Length" => reason.size.to_s
        }, reason]
      end

      # Reloads the project if reloading is enabled.  At maximum this will
      # reload the project every 5 seconds.
      def reload_project!
        monitor_project!

        # don't reload if no project or is disabled
        return false if @project.nil? || !@project.config[:reload_project]

        _did_reload = false

        if @project_did_change
          @project_did_change = false
          SC.logger.info "Rebuilding project manifest"
          @project.reload!
          _did_reload = true
        end

        _did_reload
      end

      def monitor_project!
        if !@should_monitor
          @should_monitor = true
          @project_root = @project.project_root

          # Set to an array of regular expressions matching ignored paths.
          # Listen already ignores several directories at the root of the project (including tmp).
          # Ignore all .git directories. Listen only ignores the project root .git directory by default.
          ignored_paths = [/.*\/.git\//]

          begin
            require 'listen'
            @listener = Listen.to(@project_root, ignore: ignored_paths) do |modified, added, removed|
              SC.logger.info "Detected project change.  Will rebuild manifest."
              SC.logger.debug "  modified absolute path: #{modified}"
              SC.logger.debug "  added absolute path: #{added}"
              SC.logger.debug "  removed absolute path: #{removed}"
              @project_did_change = true
            end
            @listener.start
          rescue LoadError => e
            puts $:.inspect
            puts e.message
            SC.logger.warn "The 'listen' gem was not found in your gem repository.  Falling back to polling for filesystem changes, which is much more CPU intensive.  You should run 'gem install listen' to fix this."

            # collect initial info on project
            files = Dir.glob(@project_root / '**' / '*')
            # follow 1-level of symlinks
            files += Dir.glob(@project_root / '**' / '*' / '**' / '*')

            files.reject! { |f| f =~ tmp_path }
            files.reject! { |f| File.directory?(f) }

            @project_file_count = files.size
            @project_mtime = files.map { |x| File.mtime(x).to_i }.max

            Thread.new do
              while @should_monitor
                check_for_updates

                # Add a slight delay.
                sleep 2
              end
            end
          end
        end
      end

      def check_for_updates
        # only need to start scanning again 2 seconds after the last
        # request was serviced.
        reload_delay = (Time.now - @last_reload_time)
        if reload_delay > 2
          files = Dir.glob(@project_root / '**' / '*')
          # follow 1-level of symlinks
          files += Dir.glob(@project_root / '**' / '*' / '**' / '*')
          tmp_path = /^#{Regexp.escape(@project_root / 'tmp')}/
          files.reject! { |f| f =~ tmp_path }
          files.reject! { |f| File.directory?(f) }

          cur_file_count = files.size
          cur_mtime = files.map { |x| File.mtime(x).to_i }.max

          if (@project_file_count != cur_file_count) || (@project_mtime != cur_mtime)
            SC.logger.info "Detected project change.  Will rebuild manifest"
            @project_did_change = true
            @project_file_count = cur_file_count
            @project_mtime = cur_mtime
          end
        end
      end

      def stop_monitor!
        if @listener
          @listener.stop
          @listener = nil
        end
        @should_monitor = false
      end

      def target_for(url)

        # get targets
        targets = project.targets.values.dup
        targets.each { |t| t.prepare! }

        # split the url into parts.  pop parts until we find a matching
        # target.  This ensures that we end up with the deepest matching
        # target.
        url_parts = url.split '/'
        ret = nil

        while url_parts.size>0 && ret.nil?
          url = url_parts.join '/'
          ret = targets.find { |t| t[:url_root] == url || t[:index_root] == url }
          url_parts.pop
        end
        return ret
      end

      # Helper method.  This will normalize a URL into one that can map
      # directly to an entry in the bundle.  If the URL is of a format that
      # cannot be converted, returns the url.  In particular, this will look
      # for all the different ways you can request an index.html file and
      # convert it to a canonical form
      #
      # Returns the normalized url, the language extracted from the url and
      # a boolean indicating whether the url is considered cacheable or not.
      # any url beginning with the target's url_root is considered cacheable
      # and will therefore be returned with an expires <10years> header set.
      #
      # === Params
      #  url:: the url to normalize
      #  target:: the suspected target url
      #
      # === Returns
      #  [normalized url, matched language, cacheable]
      #
      def normalize_url(url, target)

        cacheable = true

        # match
        # /foo - /foo/index.html
        # /foo/en - /foo/en/index.html
        # /foo/en/build_number - /foo/en/build_number/index.html
        # /foo/en/CURRENT/resource-name
        matched = url.match(/^#{Regexp.escape target[:index_root]}(\/([^\/\.]+))?(\/([^\/\.]+))?(\/(.*))?$/)
        unless matched.nil?
          matched_language = matched[2] || target.config[:preferred_language]

          matched_build_number = matched[4]
          if matched_build_number.blank? || matched_build_number == 'current'
            matched_build_number = target[:build_number]
          end

          resource_name = matched[6]
          resource_name = 'index.html' if resource_name.blank?

          # convert to url root based
          url = [target[:url_root], matched_language, matched_build_number,
                 resource_name] * '/'
          cacheable = false # index_root based urls are not cacheable

        # otherwise, just get the language -- url_root-based urls must be
        # fully qualified
        else
          matched = url.match(/^#{Regexp.escape  target[:url_root]}\/([^\/\.]+)/)
          matched_language = matched ? matched[1] : nil
        end

        return [url, matched_language, cacheable]
      end

      # Returns the mime type.  Basically this is the Rack mime mapper with
      # a few bug fixes.
      def mime_type(build_path, custom = {})
        ext = File.extname(build_path)

        case ext
        when '.js'
          'text/javascript'
        when '.ttf'
          'font/ttf'
        else
          custom[ext] || ::Rack::Mime.mime_type(ext, 'text/plain')
        end

      end

    end

  end
end
